/**
* PATCH /errors/{error_id}/status — エラー一覧の対応状況・備考・エラー内容を更新する(D-160 / 指示書AW 段階4-3 / 指示書BD §3-3。D-184)
*
* 仕様の正は `d-watch-api.yaml` の /errors/{error_id}/status。
*
* ★`app/api/prechecks/[id]/status/route.ts` と同じ順序★(形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し)。
* ★違いの1つ目は `error_list_id`(エラー内容)を書けること★(D-184)— 本文・計画は `lib/errors/status-update.ts`
* (事前チェックの規則の上に `error_list_id` を足したもの)、計画の途中で有効な選択肢を `db.errorLists()` で引く(必要なときだけ)。
* 違いの2つ目は ★`robot_error_reports` に `error_id` の行があれば書ける(事前チェックかどうかを問わない)★こと。★ただし削除済みの施設・清掃エリアの行は `db.errorReportById()` が null を返すので404(指示書AX U8。このルートのコードは変えていない)★
* (実在は `db.errorReportById()`、書き込みは `db.updateErrorReportColumns()`)。
* ★`/prechecks` のルートとは共通化しない★(指示書AW D12。`/prechecks` の挙動とテストを巻き込まない)—
* 同じ形の小さな関数をこのファイルに置く。
*
* ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の5列(`status` / `comment` / `responded_at` / `resolved_at` /
* `error_list_id`)だけ(D-042 / D-184 / `CLAUDE.md` §7)。★5列目の `error_list_id` を書けるのはこの経路だけ★(`/prechecks` は4列)。
* ★書き込みの中身は `lib/db/robot-error-write.ts`(`writeErrorReportColumns()`)★、★パッチを作るのは
* `planErrorStatusUpdate()` だけ★(リクエスト本文のキーが DB に届く経路を作らない。同じ列を2つの規則で書かない。D11)。
*
* ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。
* ★備考の中身は載せない★(`comment_changed` だけ。`docs/logging-design.md` の禁止キー)。
* ★エラー内容は id の前後だけ★(`error_list_id_old` / `error_list_id_new`。分類名・詳細の文字列は載せない)。
*/
import { NextResponse } from "next/server";
import { parsePathErrorId } from "@/lib/api/path-params";
import { db } from "@/lib/db";
import { columnsForErrorPatch } from "@/lib/db/robot-error-write";
import {
errorListIdToLookUp,
isEmptyErrorPatch,
parseErrorStatusUpdateBody,
planErrorStatusUpdate,
} from "@/lib/errors/status-update";
import { toJstIso } from "@/lib/format/date";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { dbErrorCode, logDbWrite } from "@/lib/logger/db-write";
import type { DbWriteLogEvent } from "@/lib/logger/events";
import type { ApiError, ErrorCode, ErrorReportItem, ErrorStatusPatch } from "@/lib/types";
const ROUTE = "/api/errors/[error_id]/status";
const METHOD = "PATCH";
const NOT_FOUND_MESSAGE = "エラーが見つかりません";
function errorBody(
code: ErrorCode,
message: string,
details: Record<string, string[]> | null = null
): ApiError {
return { error: { code, message, details } };
}
function internalError(message: string, err: unknown) {
const body = errorBody("internal_error", message);
logApiError({ route: ROUTE, method: METHOD, body, status: 500, err });
return NextResponse.json(body, { status: 500 });
}
function notFound() {
return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 });
}
/** API の ISO(+09:00)→ 真の瞬間 */
function instantOrNull(iso: string | null): Date | null {
return iso === null ? null : new Date(iso);
}
/** パッチを当てた後の日時(監査ログの `*_new`)。キーが無ければ変わらない */
function nextIso(patch: ErrorStatusPatch, key: "responded_at" | "resolved_at", before: string | null) {
if (!(key in patch) || patch[key] === undefined) return before;
const value = patch[key];
return value === null ? null : toJstIso(value);
}
/** 監査ログの共通部分(★備考の中身は載せない。`comment_changed` だけ★) */
function auditBase(
id: string,
before: ErrorReportItem,
patch: ErrorStatusPatch
): Omit<DbWriteLogEvent, "event" | "success" | "db_error_code"> {
return {
route: ROUTE,
method: METHOD,
table: "robot_error_reports",
// ★文字列のまま★(`error_id` は varchar。`Number()` を通さない。D-105 項2)
record_id: id,
columns: columnsForErrorPatch(patch),
status_old: before.resolution_status,
status_new: patch.status ?? before.resolution_status,
responded_at_old: before.responded_at,
responded_at_new: nextIso(patch, "responded_at", before.responded_at),
resolved_at_old: before.resolved_at,
resolved_at_new: nextIso(patch, "resolved_at", before.resolved_at),
error_list_id_old: before.error_list_id,
error_list_id_new: patch.error_list_id ?? before.error_list_id,
comment_changed: "note" in patch && patch.note !== undefined,
};
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ error_id: string }> }
) {
return withRequestId(request, async () => {
// 1. 形式(`error_id` は varchar(50))
const { error_id: idRaw } = await params;
const parsedId = parsePathErrorId(idRaw);
if (!parsedId.ok) {
return NextResponse.json(parsedId.error, { status: parsedId.status });
}
const id = parsedId.query;
// 2. 本文(★status / note は `/prechecks/{id}/status` と同じ検証★。加えて error_list_id の型)
let json: unknown;
try {
json = await request.json();
} catch {
return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), {
status: 422,
});
}
const parsedBody = parseErrorStatusUpdateBody(json);
if (!parsedBody.ok) {
return NextResponse.json(parsedBody.error, { status: parsedBody.status });
}
// ★現在時刻は1回だけ作る★(responded_at と resolved_at が同じ要求で別の時刻にならないように)
const now = new Date();
// 3. 実在(★参照用の接続で読む★。★大小文字は `===` で決める(D-4)★)
let before: ErrorReportItem | null;
try {
before = await db.errorReportById(id);
} catch (e) {
return internalError("データの取得に失敗しました", e);
}
if (!before) return notFound();
// 4. 計画(★検証に落ちたら422・何も変わらなければ書かずに現在の値を返す。どちらもログを出さない★)
// ★error_list_id の現在値と「選べるか」は `errorReportById()` の結果から取る★(別のクエリを撃たない)
const current = {
resolution_status: before.resolution_status,
responded_at: instantOrNull(before.responded_at),
resolved_at: instantOrNull(before.resolved_at),
note: before.note,
error_list_id: before.error_list_id,
error_list_selectable: before.error_list_selectable,
};
// ★有効な選択肢は、id が変わり・選べる行のときだけ引く★(指示書BD §3-3 手順3。`db.errorLists()` は有効な行だけを返す)
let activeErrorListIds: ReadonlySet<number> = new Set();
if (errorListIdToLookUp(current, parsedBody.query) !== null) {
try {
activeErrorListIds = new Set((await db.errorLists()).map((item) => item.id));
} catch (e) {
return internalError("データの取得に失敗しました", e);
}
}
const planned = planErrorStatusUpdate(current, parsedBody.query, now, (errorListId) =>
activeErrorListIds.has(errorListId)
);
if (!planned.ok) {
return NextResponse.json(planned.error, { status: planned.status });
}
const patch = planned.query;
if (isEmptyErrorPatch(patch)) {
return NextResponse.json(before);
}
// 5. 書き込み(★書き込み用の接続。updateMany の1文★)
const audit = auditBase(id, before, patch);
let matched: number;
try {
({ matched } = await db.updateErrorReportColumns(id, patch));
} catch (e) {
logDbWrite({ ...audit, success: false, db_error_code: dbErrorCode(e) });
return internalError("対応状況の更新に失敗しました", e);
}
if (matched === 0) {
// 確認と書き込みの間に行が消えた(ステージングのデータ入れ替え等。第10回 N-6)
logDbWrite({ ...audit, success: false, db_error_code: "no_matching_row" });
return notFound();
}
// ★書き込みが成功した時点で記録する★(読み直しの成否に関係なく、DB は変わっている)
logDbWrite({ ...audit, success: true });
// 6. 読み直し(画面はこの値でその1行を差し替える)
let after: ErrorReportItem | null;
try {
after = await db.errorReportById(id);
} catch (e) {
return internalError("データの取得に失敗しました", e);
}
if (!after) return notFound();
return NextResponse.json(after);
});
}